package com.demarc.pesquisas.negocio.dao;

import java.io.Serializable;
import java.lang.reflect.ParameterizedType;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;

import org.apache.commons.lang3.StringUtils;
import org.hibernate.Criteria;
import org.hibernate.Session;
import org.hibernate.SessionFactory;
import org.hibernate.criterion.Conjunction;
import org.hibernate.criterion.Criterion;
import org.hibernate.criterion.Disjunction;
import org.hibernate.criterion.Example;
import org.hibernate.criterion.MatchMode;
import org.hibernate.criterion.Order;
import org.hibernate.criterion.Projections;
import org.hibernate.criterion.Restrictions;
import org.hibernate.sql.JoinType;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.transaction.annotation.Transactional;

import com.demarc.pesquisas.infraestrutura.excecao.NegocioException;
import com.demarc.pesquisas.infraestrutura.persistencia.ColecaoChecada;
import com.demarc.pesquisas.infraestrutura.persistencia.Filtro;
import com.demarc.pesquisas.infraestrutura.persistencia.ObjetoPersistenteVersionado;
import com.demarc.pesquisas.infraestrutura.persistencia.ResultadoPaginado;
import com.demarc.pesquisas.negocio.util.ConstantesMensagens;

public abstract class AbstractDAO<T extends ObjetoPersistenteVersionado<ID>, ID extends Serializable>
        implements Serializable {

    private static final String UNCHECKED = "unchecked";

    @Autowired
    private SessionFactory sessionFactory;

    private SessionFactory getSessionFactory() {
        return sessionFactory;
    }

    public Session getSession() {
        return getSessionFactory().getCurrentSession();
    }

    @SuppressWarnings(UNCHECKED)
    private Class<T> getClassPersistente() {
        return (Class<T>) ((ParameterizedType) getClass().getGenericSuperclass())
                .getActualTypeArguments()[0];
    }

    /**
     * Cria um objeto do tipo Criteria com a classe persistente do DAO.
     * 
     * @return um objeto do tipo Criteria com a classe persistente do DAO.
     */
    protected Criteria createCriteria() {
        Session currentSession = getSession();
        return currentSession.createCriteria(getClassPersistente());
    }

    /**
     * Cria um objeto do tipo Criteria com a classe passada como parâmetro.
     * 
     * @return um objeto do tipo Criteria.
     */
    protected Criteria createCriteria(Class<?> classe) {
        return getSession().createCriteria(classe);
    }

    protected final Example createExample(T objeto) {
        Example example = Example.create(objeto);
        example.enableLike(MatchMode.ANYWHERE);
        example.excludeZeroes();
        example.ignoreCase();

        return example;
    }

    /**
     * Persists an object in database.
     * 
     * @param obj Object that will be inserted in database.
     */
    @Transactional(rollbackFor = Exception.class)
    public void salvar(T obj) {
        getSession().save(obj);
    }

    @Transactional(rollbackFor = Exception.class)
    public void alterar(T entidade) {
        getSession().update(entidade);
    }

    @Transactional(rollbackFor = Exception.class)
    public void salvarOuAlterar(T entidade) {
        getSession().saveOrUpdate(entidade);
    }

    @Transactional(rollbackFor = Exception.class)
    public void excluir(ID obj) {
        getSession().delete(buscarComConcorrencia(obj));
    }

    @Transactional(rollbackFor = Exception.class)
    public void excluir(Object entidade) {
        getSession().delete(entidade);
    }

    public <D extends Serializable> void merge(D entidade) {
        getSession().merge(entidade);
    }

    private T buscarComConcorrencia(ID id) {
        T object = getClassPersistente().cast(getSession().get(getClassPersistente(), id));

        if (object == null) {
            throw new NegocioException(ConstantesMensagens.GERAL_ERRO_ACESSO_CONCORRENTE);
        }
        return object;
    }

    /**
     * Retrieve Object from given ID.
     * 
     * @param id Object identifier.
     * @return Object with given ID.
     * @throws NonExistentObjectException if there is no object with given id.
     */
    public T buscarPor(ID id) {
        return getClassPersistente().cast(getSession().get(getClassPersistente(), id));
    }

    /**
     * Retrieve object from given identifier without add in session.
     * 
     * @param Object identifier
     * @return Informed object with given id.
     */
    public T buscarDesconectado(ID id) {
        T entity = getClassPersistente().cast(getSession().get(getClassPersistente(), id));

        if (entity != null) {
            desconectar(entity);
        }
        return entity;
    }

    /**
     * Recupera todos os registros de uma tabela.
     * 
     * @return Lista contendo todos os registros.
     */
    public List<T> listarOrdenadoPor(String... atributosEOrdem) {
        Criteria criteria = createCriteria();

        if (atributosEOrdem.length > 0) {
            Map<String, Boolean> mapaOrdem = criarOrdem(atributosEOrdem);
            adicionarOrdenacao(criteria, mapaOrdem);
        }

        return ColecaoChecada.checkedList(criteria.list(), getClassPersistente());
    }

    /**
     * Recupera todos os registros de uma tabela.
     * 
     * @return Lista contendo todos os registros.
     */
    @SuppressWarnings(UNCHECKED)
    public <E> List<E> listarPor(String property) {
        Criteria criteria = createCriteria();

        if (StringUtils.isNotEmpty(property)) {
            criteria.setProjection(Projections.property(property));
        }
        return criteria.list();
    }

    /**
     * Recupera o maior registro de uma tabela.
     * 
     * @return coluna com maior valor no banco
     */
    public <E> E buscarMaiorPor(String property, Filtro filtro) {
        Criteria criteria = createCriteria();

        adicionarRestrincaoConsultaPorFiltro(filtro, criteria);

        if (StringUtils.isNotEmpty(property)) {
            criteria.setProjection(Projections.max(property));
        }

        return uniqueResult(criteria);
    }

    private void adicionarOrdenacao(Criteria criteria, Map<String, Boolean> propriedades) {
        if (propriedades != null) {
            for (Entry<String, Boolean> entry : propriedades.entrySet()) {
                if (entry.getValue()) {
                    criteria.addOrder(Order.asc(entry.getKey()).ignoreCase());
                } else {
                    criteria.addOrder(Order.desc(entry.getKey()).ignoreCase());
                }
            }
        }
    }

    /**
     * Uso do filtro:<br/>
     * 
     * Filtrando valores indesejados: filtro.put("!campo", "valor");<br/>
     * Por padrão, esse método procura campos string usando MatchMode.ANYWHERE,<br/>
     * se quiser usar MatchMode.EXACT você deve usar "==" <br/>
     * no início do nome do campo: filtro.put("==campo", "valor");<br/>
     * Você pode usar a "!" e "==", ao mesmo tempo: filtro.put("!==campo", "valor");
     * 
     * @param filtro Filtro para consulta.
     * @return Resultado paginado com lista de T e quantidade total de registros.
     */
    @SuppressWarnings(UNCHECKED)
    public ResultadoPaginado<T> consultarPorFiltro(Filtro filtro) {

        int quantidade = this.quantidadeLinhasConsultaPorFiltro(filtro);
        List<T> resultado = new ArrayList<T>();
        if (quantidade > 0) {
            Criteria criteria = createCriteria();
            this.adicionarRestrincaoConsultaPorFiltro(filtro, criteria);
            resultado = criteria.list();
        }

        return new ResultadoPaginado<T>(resultado, quantidade);
    }

    protected void adicionarRestrincaoConsultaPorFiltro(Filtro filtro, Criteria criteria) {

        Set<String> aliases = adicionarCampos(filtro, criteria);
        adicionarOrdenacao(criteria, filtro.getOrdem(), aliases);
        criteria.setFirstResult(filtro.getPrimeiroElemento());
        criteria.setMaxResults(filtro.getQuantidadeMaximaResultados());
    }

    public List<T> consultarLimitando(T entity, int indiceInicial, int indiceFinal,
            Order... ordenacoes) {

        Example example = createExample(entity);
        Criteria criteria = createCriteria().add(example);
        criteria.setFirstResult(indiceInicial);
        criteria.setMaxResults(indiceFinal);

        for (int i = 0; i < ordenacoes.length; i++) {
            criteria.addOrder(ordenacoes[i]);
        }
        return ColecaoChecada.checkedList(criteria.list(), getClassPersistente());
    }

    /**
     * 
     * Adiciona uma restrição do tipo sobreposição na consulta.
     * 
     * @param criteria onde será adicionado a restrição.
     * @param campoInicial o nome do campo referente a data ou número inicial da consulta.
     * @param campoFinal o nome do campo referente a data ou número final da consulta.
     * @param inicio o valor de ou número data inicial da restrição.
     * @param fim o valor de data ou número final da restrição.
     */
    protected void adicionarRestricoesSobreposicao(Criteria criteria, String campoInicial,
            String campoFinal, Object inicio, Object fim) {
        Disjunction disjuncao =
                obterRestricaoSobreposicaoDatas(campoInicial, campoFinal, inicio, fim);
        criteria.add(disjuncao);
    }

    /**
     * Retorna uma restrição para verificar se as propriedades da consulta estão no período
     * informado.
     * 
     * @param campoInicial o nome do campo referente a data ou número inicial da consulta.
     * @param campoFinal o nome do campo referente a data ou n�número final da consulta.
     * @param inicio o valor de ou número data inicial da restrição.
     * @param fim o valor de data ou número final da restrição.
     */
    protected static Disjunction obterRestricaoSobreposicaoDatas(String campoInicial,
            String campoFinal, Object inicio, Object fim) {
        Disjunction disjuncao = Restrictions.disjunction();

        Conjunction condicaoSemDataFimMenorIgualDataInicio = Restrictions.conjunction();
        condicaoSemDataFimMenorIgualDataInicio.add(Restrictions.le(campoInicial, inicio));
        condicaoSemDataFimMenorIgualDataInicio.add(Restrictions.isNull(campoFinal));
        disjuncao.add(condicaoSemDataFimMenorIgualDataInicio);

        if (fim == null) {
            // Os campos inicial e final estão entre o parâmetro data inicio.
            Conjunction conjunctionComDataFim = Restrictions.conjunction();
            conjunctionComDataFim.add(Restrictions.le(campoInicial, inicio));
            conjunctionComDataFim.add(Restrictions.ge(campoFinal, inicio));

            disjuncao.add(conjunctionComDataFim);

            Conjunction condicaoSemDataFimMaiorIgualDataInicio = Restrictions.conjunction();
            condicaoSemDataFimMaiorIgualDataInicio.add(Restrictions.ge(campoInicial, inicio));

            disjuncao.add(condicaoSemDataFimMaiorIgualDataInicio);

        } else {
            // O campo inicial está entre os parâmetros inicial e final.
            disjuncao.add(Restrictions.between(campoInicial, inicio, fim));
            // O campo final está entre os parâmetros inicial e final.
            disjuncao.add(Restrictions.between(campoFinal, inicio, fim));
            // Os parâmetros estão entre os campos inicial e final.
            disjuncao.add(Restrictions.and(Restrictions.le(campoInicial, inicio), //
                    Restrictions.ge(campoFinal, fim)));

            Conjunction condicaoComDataFimMenorIgualDataFim = Restrictions.conjunction();
            condicaoComDataFimMenorIgualDataFim.add(Restrictions.le(campoInicial, fim));
            condicaoComDataFimMenorIgualDataFim.add(Restrictions.isNull(campoFinal));
            disjuncao.add(condicaoComDataFimMenorIgualDataFim);
        }
        return disjuncao;
    }

    protected int quantidadeLinhasConsultaPorFiltro(Filtro filtro) {
        Criteria quantidadeCriteria = createCriteria();
        adicionarCampos(filtro, quantidadeCriteria);
        int quantidade = getTotalRegistros(quantidadeCriteria).intValue();
        return quantidade;
    }

    @SuppressWarnings(UNCHECKED)
    public List<T> consultarPorCampo(String nomeCampo, Object valorCampo, String... atributosEOrdem) {
        Criteria criteria = createCriteria(getClassPersistente());

        if (atributosEOrdem.length > 0) {
            Map<String, Boolean> ordemMap = criarOrdem(atributosEOrdem);
            adicionarOrdenacao(criteria, ordemMap, new HashSet<String>());
        }

        if (valorCampo instanceof String) {
            criteria.add(Restrictions.eq(nomeCampo, valorCampo).ignoreCase());
        } else {
            criteria.add(Restrictions.eq(nomeCampo, valorCampo));
        }
        return criteria.list();
    }

    private Map<String, Boolean> criarOrdem(String... attributesAndOrder) {
        Map<String, Boolean> orderMap = new LinkedHashMap<String, Boolean>();

        for (String attributeAndOrder : attributesAndOrder) {
            String value;
            String attribute;
            boolean order;
            int tokenIndex = attributeAndOrder.indexOf(':');

            if (tokenIndex > 0) {
                attribute = attributeAndOrder.substring(0, tokenIndex);
                value = attributeAndOrder.substring(tokenIndex + 1, attributeAndOrder.length());
            } else {
                attribute = attributeAndOrder;
                value = "asc";
            }
            if ("asc".equalsIgnoreCase(value)) {
                order = true;
            } else if ("desc".equalsIgnoreCase(value)) {
                order = false;
            } else {
                throw new RuntimeException("Invalid attribute ordering syntax: "
                        + attributeAndOrder);
            }
            orderMap.put(attribute, order);
        }
        return orderMap;
    }

    protected Set<String> adicionarCampos(Filtro filter, Criteria criteria) {
        Set<String> aliases = new HashSet<String>();

        for (Entry<String, Object> entry : filter.entrySet()) {
            String atributo = entry.getKey();

            Boolean isNegacao = Boolean.FALSE;
            Boolean isValorExato = Boolean.FALSE;

            // Nega a verificação
            if (atributo.charAt(0) == '!') {
                isNegacao = Boolean.TRUE;
                atributo = atributo.substring(1);
            }

            // Verifica se a restrição deve ser exata
            if (atributo.startsWith("==")) {
                isValorExato = Boolean.TRUE;
                atributo = atributo.substring(2);
            }

            Object valor = entry.getValue();

            if (isNaoVazio(valor)) {

                // Preparando as alias
                String alias = getAlias(atributo);

                if (StringUtils.isNotBlank(alias) && !aliases.contains(alias)) {
                    aliases.add(alias);
                    criteria.createAlias(alias, alias);
                }

                // Adicionando restrição
                Criterion criterion = null;

                if (valor.equals(Filtro.VALOR_NULO)) {
                    criterion = Restrictions.isNull(atributo);
                } else if (valor.equals(Filtro.VALOR_NAO_NULO)) {
                    criterion = Restrictions.isNotNull(atributo);
                } else if (valor.equals(Filtro.VALOR_IS_EMPTY)) {
                    criterion = Restrictions.isEmpty(atributo);
                } else if (valor instanceof String) {

                    // Verifica se a restrição de string é exata
                    if (isValorExato) {
                        criterion =
                                Restrictions.ilike(atributo, ((String) valor).toLowerCase(),
                                        MatchMode.EXACT);
                    } else {
                        // Se não, consulta em qualquer posição
                        criterion =
                                Restrictions.ilike(atributo, ((String) valor).toLowerCase(),
                                        MatchMode.ANYWHERE);
                    }

                } else if (valor instanceof Collection) {
                    criterion = Restrictions.in(atributo, (Collection<?>) valor);
                } else {
                    criterion = Restrictions.eq(atributo, valor);
                }

                // Caso negação
                if (isNegacao) {
                    criterion = Restrictions.not(criterion);
                }
                criteria.add(criterion);
            }
        }
        return aliases;
    }

    private boolean isNaoVazio(Object valor) {
        boolean result;

        if (valor instanceof String) {
            result = StringUtils.isNotBlank((String) valor);
        } else {
            result = valor != null;
        }
        return result;
    }

    private String getAlias(String atributo) {
        String alias = null;
        int index = atributo.indexOf('.');

        if (index > 0) {
            alias = atributo.substring(0, index);
        }
        return alias;
    }

    protected void adicionarOrdenacao(Criteria criteria, Map<String, Boolean> ordem,
            Set<String> aliases) {

        for (Entry<String, Boolean> entry : ordem.entrySet()) {

            String atributo = entry.getKey();
            Boolean ascendente = entry.getValue();

            String alias = getAlias(atributo);
            if (org.apache.commons.lang3.StringUtils.isNotBlank(alias) && !aliases.contains(alias)) {
                aliases.add(alias);
                criteria.createAlias(alias, alias, JoinType.LEFT_OUTER_JOIN);
            }

            if (ascendente) {
                criteria.addOrder(Order.asc(atributo).ignoreCase());
            } else {
                criteria.addOrder(Order.desc(atributo).ignoreCase());
            }
        }
    }

    protected boolean existeResultado(Criteria criteria) {
        return getTotalRegistros(criteria) > 0;
    }

    /**
     * Recupera o total de registros a partir de uma criteria.
     * 
     * @param criteria Criteria utilizada.
     */
    protected Long getTotalRegistros(Criteria criteria) {
        criteria.setProjection(Projections.rowCount());
        return (Long) criteria.uniqueResult();
    }

    protected Long getTotalRegistrosComDistinct(Criteria criteria, String propertyName) {
        criteria.setProjection(Projections.countDistinct(propertyName));
        return (Long) criteria.uniqueResult();
    }

    public boolean existe(ID id) {
        return ((Long) createCriteria().add(Restrictions.idEq(id))
                .setProjection(Projections.rowCount()).uniqueResult()) > 0;
    }

    /**
     * Recupera um unico registro de uma consulta, retornando o objeto específico
     * 
     * Obs: Metodo criado para evitar o (cast) em todas as consultas utilizando criteria para obter
     * um unico objeto
     * 
     * @param criteria
     */
    @SuppressWarnings(UNCHECKED)
    public <Retorno> Retorno uniqueResult(Criteria criteria) {
        return (Retorno) criteria.uniqueResult();
    }

    /**
     * Remove session hibernate instance. To remove cascade relationship must be used
     * cascade="evict".
     * 
     * @param obj Object to disconnect.
     */
    public <D extends Serializable> void desconectar(D obj) {
        if (getSession().contains(obj)) {
            getSession().evict(obj);
        }
    }

    /**
     * Remove object list from hibernate session. To remove cascade relationships cascade="evict"
     * must be used.
     * 
     * @param obj Object list to be disconnected
     */
    public <D extends Serializable> void desconectar(List<D> objs) {
        if (objs != null && !objs.isEmpty()) {
            for (D obj : objs) {
                getSession().evict(obj);
            }
        }
    }
}
